feat(billing): launch Pro and Team plans with single-seat subscriptions#705
Conversation
- Add user-facing billing doc at docs/platform/billing.mdx covering plans, AI credits, top-ups, $0.25 floor, per-seat Pro, and team credit pooling. - Add FAQ section to /pricing linking to the billing guide. - Remove legacy/dishonest features (Advanced Analytics, Google Sheets, Notion, Toss, Ticketing for Events, KakaoTalk, WhatsApp, Credits Rollover); drop Ticketing category and move Simulator under Forms. - Rename "Generated Image License" -> "Generated Media License"; gate "Buy extra credits" from Pro. - Reorder comparison table so Support is the last section.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
WalkthroughAdds a Stripe-backed billing subsystem: DB schema and migrations, public wrapper functions and pgTAP tests, server-side billing library and webhook receiver, org-scoped billing UI/pages and server actions, E2E fixtures/tests, pricing/marketing updates, tooling/config, and contributor docs. ChangesStripe Billing System
Possibly related PRs
Estimated code review effort🎯 5 (Critical) | ⏱️ ~120 minutes Suggested labels
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
|
Bring the billing guide and pricing FAQ in line with the live plans data: four plans (Free / Pro / Team / Enterprise), Pro and Team both per-seat with their own per-seat AI credit allotment ($10 and $35), 20% annual discount available on both. Removes the stale "no separate Team plan" claim and updates examples (Priya's team upgrades to Team, Sam's Pro renewal shows $10 monthly credit).
Add four behavior-spec files under `test/` covering the full v1 billing surface: subscription lifecycle and Stripe consistency, quota math and the AI gate, payment failures and fraud vectors, and user-facing UX + operational edge cases. Cases are written as natural-language flows (symptoms + expected outcomes), implementation-agnostic so the spec survives schema iteration. Also correct the top-up fee math in the user-facing billing guide ($25.73 → $26.06, accounting for the fee-on-fee), and trim a stale trailing FAQ entry.
…ing_tier Introduces the Stripe-backed billing schema (grida_billing.*) with account, customer, subscription, invoice, payment_method, stripe_event, billing_audit, and product_catalogue tables, plus the fn_billing_apply_stripe_event projector and supporting RPCs. Adds 38 pgTAP scenarios covering RLS, projector branches, idempotency, annual catalogue resolution, and the new is_enterprise flag. Drops the unused public.organization.display_plan column and public.pricing_tier enum — plan is now derived from v_billing_subscription. Adds organization.is_enterprise boolean as a separate ops flag. Includes the contributor setup guide at docs/contributing/billing.md.
…lint rule - editor/lib/billing/index.ts: single Stripe seam exporting the typed client, BillingError, auth helpers, redirect-URL validation, data helpers, and the dispatchStripeEvent projector entry point. - editor/.oxlintrc.jsonc: enforces the seam — no-restricted-imports blocks 'stripe' value imports outside lib/billing/index.ts. - editor/lib/supabase/server.ts: documents the inline-callsite rule for service_role.<schema>.* (don't alias — preserves grep-ability). - editor/scripts/billing/setup-stripe-test.ts: idempotent test-mode setup of products, monthly+annual prices, and Customer Portal config. - tsconfig: exclude scripts/ from typecheck (script lives outside the Next.js compile root).
Verifies the Stripe signature and dispatches the event into the PL/pgSQL projector via dispatchStripeEvent. Signature verification runs before any DB write; tampered or missing signatures return 400 without touching grida_billing.stripe_event.
- /organizations/<org>/settings/billing — Subscription Plan, Past Invoices, Payment Methods. Adjust plan and Cancel buttons guarded by past_due/unpaid/paused/incomplete states. - /settings/billing/upgrade — Monthly/Annual toggle, plan cards (Free → Paid via Stripe Checkout, paid → paid via Customer Portal flow_data subscription_update_confirm). Embedded as a modal via parallel-route @modal slot for in-place upgrade. - Server actions: getBillingSummary, listInvoices, updateSeats, startSubscribeCheckout, startPlanChangeConfirm, startCancelSubscription, startPaymentMethodUpdate, listBillingAudit. - Settings shell with sidebar nav (Profile / Billing / Members). Profile page drops its bespoke breadcrumb header. - host/url.ts: adds 'organization' route scope so universal links can target /organizations/<org>/<path>. - Universal route picker handles organization-scope: redirects when the user has exactly one org, otherwise renders an org selector.
- types: PlatformPricingTier (free|v0_pro|v0_team|v0_enterprise) →
PlanTier (free|pro|team). Enterprise becomes a separate
organization.is_enterprise flag.
- /api/private/workspace/[organization_id]: resolves each org's plan
from v_billing_subscription and exposes it as Organization.plan.
Falls back to "free" when there's no active subscription.
- Workspace sidebar PricingTierCard reads { plan, is_enterprise } and
links the Upgrade button to /settings/billing/upgrade.
- Org switcher badge uses Labels.planTier(plan, is_enterprise).
- Workspace reducer's init/organizations action refreshes
state.organization with the SWR-fetched value, so the server-render
default of plan:"free" gets replaced once real data lands.
Annual is the upsell, not the headline price. New visitors should see the real monthly default first, then opt into the annual view.
- editor/lib/billing/__tests__/e2e: lifecycle, idempotency, and tampered-signature scenarios. Hits real Stripe test mode and the local webhook receiver via the deliver-event fixture; assertSuiteSafety refuses to run unless STRIPE_SECRET_KEY is sk_test_ and BILLING_TEST_MODE is true. - editor/.env.test: non-secret defaults (NODE_ENV=test, BILLING_E2E=1 off-by-default in shell, local Supabase URL). Secrets go in .env.test.local (gitignored). Untracks .env.test from .gitignore. - editor/vitest.config.ts: inline parser auto-loads .env.test + .env.test.local. E2E suite is excluded from default runs and runs sequentially when BILLING_E2E=1 (shared Stripe rate limits).
Adds SUB-052..058 covering the same-plan interval upgrade flow (Pro monthly → Pro annual, Team monthly → Team annual) routed through Customer Portal subscription_update_confirm.
|
@coderabbitai review |
✅ Actions performedReview triggered.
|
2 similar comments
✅ Actions performedReview triggered.
|
✅ Actions performedReview triggered.
|
There was a problem hiding this comment.
Actionable comments posted: 1
Note
Due to the large number of review comments, Critical severity comments were prioritized as inline comments.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
editor/host/url.ts (1)
334-341:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail fast when required route context is missing.
The loose overload now allows
proj/docIdto be absent, but this implementation interpolates them directly, so callers can silently get/acme/undefined/...or/acme/proj/null. Throwing here is much safer than emitting broken links.Suggested fix
if (route.scope === "organization") { const base = `/organizations/${context.org}`; return suffix ? `${base}/${suffix}` : base; } + if (!context.proj) { + throw new Error(`buildUniversalDestination(${page}): missing project context`); + } const base = `/${context.org}/${context.proj}`; if (route.scope === "project") { return suffix ? `${base}/${suffix}` : base; } - const docId = "docId" in context ? context.docId : ""; + if (!context.docId) { + throw new Error(`buildUniversalDestination(${page}): missing document context`); + } + const docId = context.docId; return suffix ? `${base}/${docId}/${suffix}` : `${base}/${docId}`;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/host/url.ts` around lines 334 - 341, The current URL builder allows missing required context (proj/docId) and may produce invalid paths; update the logic around context, route.scope, base, and suffix to validate required fields and throw if absent: when route.scope === "project" assert that context.proj exists (throw a descriptive Error if not), and for non-project routes assert both context.proj and context.docId exist (throw if either is missing) before composing base and returning `${base}/${suffix}` or `${base}/${docId}/${suffix}`; use the existing symbols context, route.scope, proj, docId, base, and suffix to locate and implement the checks and throws.editor/www/data/pricing.ts (1)
68-77:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon’t mark extra-credit purchases as available before the topup flow ships.
This table now advertises
"Buy extra credits"as enabled for paid plans, but the rest of this billing work still treats topups as deferred/future behavior. Shipping this astruewill send users to a capability they can’t actually use yet.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/www/data/pricing.ts` around lines 68 - 77, The "Buy extra credits" pricing entry currently advertises availability for paid plans but the topup flow isn't implemented; locate the pricing object whose title is "Buy extra credits" in editor/www/data/pricing.ts and set the plans flags (e.g., pro, team, enterprise) to false (and/or flip usage_based as appropriate) so the UI no longer shows this option as enabled until the topup feature ships.editor/scaffolds/workspace/workspace.tsx (1)
147-155:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDefaulting every org to
freecreates a transient downgrade on first paint.Paid organizations will briefly look free until the SWR refresh lands, so any plan-based label or gating read from
state.organizationcan flicker or disable paid UX incorrectly. It’s safer to preserve the server value when present, or model this as unknown/loading instead of hard-codingfree.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/scaffolds/workspace/workspace.tsx` around lines 147 - 155, The current workspace organization object hard-codes plan: "free" which causes a transient downgrade; change the plan assignment in the organization spread inside workspace.tsx to preserve an existing server value when present (e.g., use organization.plan or state.organization.plan) or set it to an explicit unknown/loading value instead of always "free", so UI reads the real plan until SWR refresh replaces state.organization; update references around the organization spread and the plan property to use the preserved value or a loading sentinel rather than unconditionally "free".test/billing-payment-and-money-safety.md (1)
1-264: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftThis
test/**/*.mdfile is acting like a broad billing spec, not a manual UX test case.Most of these scenarios are webhook/state/money-safety invariants that belong in automated tests or product docs, and the file also bundles dozens of independent behaviors without a
## Stepssection. Please split out any true human-verification UX cases and move the automatable/payment-policy coverage elsewhere.As per coding guidelines,
test/**/*.md: "Add manual test cases to thetestdirectory only for UX bugs requiring human interaction verification ... not for pure logic, math, or data transformations", "Ensure each manual test case file covers only one independent behavior", and "Write the## Stepssection in manual test cases with sufficient detail for someone unfamiliar with the feature to verify the behavior".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/billing-payment-and-money-safety.md` around lines 1 - 264, This file mixes many automatable billing invariants and policy specs into a single manual test file; split it into: (1) a concise manual UX test(s) that each cover only one human-verification behavior with a clear "## Steps" section (e.g., create separate files for any test that requires human UI interaction), and (2) move all pure logic/webhook/money-safety scenarios (e.g., TC-BILLING-PAY-001 through TC-BILLING-PAY-044) into automated test suites or product/policy docs; ensure each new manual MD contains only one behavior and step-by-step verification instructions, and name files using the existing identifiers (TC-BILLING-PAY-### headings) so the specific cases are easy to locate.editor/lib/billing/marketing-plans.ts (1)
74-87:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThe pricing copy still advertises multi-seat billing.
Both paid tiers say
per seat/monthandSeats: ♾️, but this PR ships single-seat subscriptions only. That will promise functionality the billing flow cannot actually sell.Also applies to: 96-109
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/lib/billing/marketing-plans.ts` around lines 74 - 87, Update the pricing copy to reflect single-seat subscriptions: change the costUnit value from "per seat/month" to "per month" for each plan and update the corresponding feature entry in the features array that has name "Seats" (replace the trail "♾️" with "1" or remove the Seats feature entirely) so the UI no longer advertises multi-seat billing; apply the same edits for both plan objects referenced around costUnit and features (the block containing priceMonthly, costUnit, and the features array).
🟠 Major comments (20)
editor/app/(www)/(pricing)/pricing/_sections/faq.tsx-56-59 (1)
56-59:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFAQ describes multi-seat billing mechanics that are explicitly deferred in this PR.
The PR description states: "Single-seat subscriptions only: every subscription is created and maintained with
quantity = 1; multi-seat billing deferred (tracked: GRIDA-60)."Yet this FAQ item tells users that adding a member "adds a seat (prorated for the rest of the period)", removing one "credits the next invoice", and gives a concrete 5-seat pricing example — all of which currently behave differently (quantity stays at 1). Users who rely on this as documentation will be confused and file support tickets when seat-syncing doesn't happen.
Consider either:
- Replacing the forward-looking mechanics with the actual current behaviour ("seats are billed manually for now; multi-seat automation is coming"), or
- Deferring this FAQ item until GRIDA-60 ships.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(www)/(pricing)/pricing/_sections/faq.tsx around lines 56 - 59, The FAQ entry under pricing/_sections/faq.tsx that has question "How does pricing work for teams?" currently describes multi-seat billing mechanics; update the answer to reflect the current single-seat behavior (every subscription is created/maintained with quantity = 1 and multi-seat automation is deferred: reference GRIDA-60) or remove/hold the entire FAQ object until GRIDA-60 ships; modify the object whose question string equals "How does pricing work for teams?" so the answer explicitly states seats are billed manually / multi-seat support is not yet implemented and avoid concrete prorated/5-seat examples.editor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsx-4-9 (1)
4-9:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
Reactis used as a type namespace but never imported — this will cause a TypeScript compilation error.
React.ReactNodereferences the React namespace at the type level, but unlike JSX syntax (handled transparently by thereact-jsxtransform), namespace type references still require an explicit import. The project's TypeScript config usesjsx: "react-jsx"which allows JSX without imports, but the compiler will fail onReact.ReactNodewithout the namespace in scope. Compare witheditor/app/(embed)/embed/layout.tsxwhich correctly importstype { ReactNode } from "react"and uses it directly.🐛 Proposed fix
+import type { ReactNode } from "react"; export default function BillingLayout({ children, modal, }: { - children: React.ReactNode; - modal: React.ReactNode; + children: ReactNode; + modal: ReactNode; }) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/layout.tsx around lines 4 - 9, The file declares BillingLayout using React.ReactNode types but never imports React, causing a TypeScript error; update the top of the file to import the types (for example: import type { ReactNode } from "react") and change the prop annotations to use ReactNode (or alternatively import React itself) so that the BillingLayout props (children, modal) compile correctly; refer to the BillingLayout function and the React.ReactNode type usage when making the change.editor/lib/billing/__tests__/e2e/fixtures/org.ts-80-93 (1)
80-93:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon’t silently succeed after Stripe cleanup failures.
If subscription cancellation delivery or customer deletion fails here, teardown can still complete locally and leave orphaned Stripe state behind for later runs. For the only cleanup path in these E2E fixtures, that turns transient cleanup failures into hidden account pollution.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts` around lines 80 - 93, The current teardown swallows Stripe failures (in the subscription cancellation catch and the stripe.customers.del catch), logging warnings and continuing which leaves orphaned Stripe state; change both catch blocks to surface failures by rethrowing the original error (or throw a new Error with the original message) after logging so the E2E fixture teardown fails instead of silently succeeding—look for the subscription cancellation try/catch and the stripe.customers.del call in this file and replace their console.warn-only catches with code that logs then throws the error.editor/host/url.ts-52-58 (1)
52-58:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
settingsnow collides with the existing document-scoped universal route.There is already a document-scoped
settingsentry below. Adding anothersettingspath here means the organization route overwrites the document route inUNIVERSAL_ROUTE_CONFIGS, and even a later id-only fix would still leave/_/settingsambiguous. The organization pages need a distinct universal path/id namespace.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/host/url.ts` around lines 52 - 58, ORGANIZATION_ROUTE_CONFIGS introduces a duplicate "settings" key that collides with the document-scoped "settings" entry in UNIVERSAL_ROUTE_CONFIGS; rename the org-scoped keys to a distinct namespace (e.g., "organization/settings" or "org/settings" and corresponding "organization/settings/billing", "organization/settings/billing/upgrade") and update any places that merge or reference ORGANIZATION_ROUTE_CONFIGS so the org routes no longer overwrite the document-scoped "settings" entry in UNIVERSAL_ROUTE_CONFIGS.editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts-75-80 (1)
75-80:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThe non-JSON fallback loses the original response body.
res.json()consumes the stream before throwing on invalid JSON, so theres.text()call in the catch will usually fail too. That makes the helper least useful when the webhook returns an HTML/plaintext error page.Suggested fix
let body: DeliverResult["body"]; - try { - body = (await res.json()) as DeliverResult["body"]; - } catch { - body = { error: `non-json: ${await res.text()}` }; - } + const raw = await res.text(); + try { + body = JSON.parse(raw) as DeliverResult["body"]; + } catch { + body = { error: `non-json: ${raw}` }; + } return { status: res.status, body };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/lib/billing/__tests__/e2e/fixtures/deliver-event.ts` around lines 75 - 80, The fallback loses the original response because res.json() consumes the body and prevents res.text() from reading it; modify the handler in this fixture so you first read the raw text (e.g., via await res.clone().text() or await res.text()), store it, then attempt to parse JSON from that raw string and set body as DeliverResult["body"] accordingly (use the stored raw for the non-JSON error case). Update the logic around res.json()/res.text() to avoid consuming the stream twice and ensure the original response body is preserved in the fallback.editor/app/(workspace)/universal/[[...path]]/page.tsx-59-62 (1)
59-62:⚠️ Potential issue | 🟠 Major | ⚡ Quick winHandle the membership query failure instead of rendering the empty state.
If this select errors,
membershipsbecomesnulland the branch tells the user they have no organizations. That masks auth/RLS/backend failures as a normal empty result.Suggested fix
- const { data: memberships } = await client + const { data: memberships, error: membershipsError } = await client .from("organization_member") .select(`organization:organization(name)`) .eq("user_id", auth.user.id); + if (membershipsError) throw membershipsError;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(workspace)/universal/[[...path]]/page.tsx around lines 59 - 62, The membership query currently destructures only data so query failures (error != null) produce memberships === null and are shown as an empty state; update the call that uses client.from(...).select(...) to also destructure the error (e.g., const { data: memberships, error } = await client.from(...).select(...).eq(...)), then branch on error before rendering: either throw or render an error UI/log the error via your logger instead of treating null as "no organizations" — make this change where memberships is used so the component/page (the [[...path]] page) distinguishes real empty results from backend/auth/RLS failures.supabase/tests/test_grida_billing_test.sql-282-307 (1)
282-307: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winMissing write-isolation coverage on grida_billing tables (per RLS guideline).
Section 9 only proves read isolation on
v_billing_subscriptionfor insider/alice/random. The guideline requires both read and write isolation tests (insert/update/delete) for every table that alters RLS or tenant boundaries. Direct writes togrida_billing.subscription,grida_billing.account, andgrida_billing.auditbyauthenticatedusers (and tov_billing_subscriptionif that view is updatable) should be asserted to fail — that's what locks the "service_role-only mutation" invariant from the PR description into a regression test.Suggested additions (sketch):
SET LOCAL ROLE authenticated; SELECT set_config('request.jwt.claim.sub', current_setting('test.alice_uid'), true); SELECT throws_ok($$ INSERT INTO grida_billing.subscription (organization_id, plan, status, is_free, quantity) VALUES (2, 'pro', 'active', false, 1) $$, NULL, NULL, 'authenticated cannot INSERT into grida_billing.subscription'); SELECT throws_ok($$ UPDATE grida_billing.subscription SET status='canceled' WHERE organization_id=2 $$, NULL, NULL, 'authenticated cannot UPDATE grida_billing.subscription'); SELECT throws_ok($$ DELETE FROM grida_billing.subscription WHERE organization_id=2 $$, NULL, NULL, 'authenticated cannot DELETE from grida_billing.subscription'); RESET ROLE;(Also bump
plan(37)accordingly.)As per coding guidelines: "Add pgTAP test coverage for every table that alters RLS, permissions, or tenant boundaries, including at minimum read isolation tests and write isolation tests (insert/update/delete)".
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/tests/test_grida_billing_test.sql` around lines 282 - 307, Add write-isolation pgTAP assertions for grida_billing tables and the v_billing_subscription view: while running as SET LOCAL ROLE authenticated and after setting request.jwt.claim.sub (e.g., current_setting('test.alice_uid') and a random user), assert that INSERT/UPDATE/DELETE on grida_billing.subscription, grida_billing.account, grida_billing.audit (and v_billing_subscription if updatable) all throw using throws_ok so authenticated cannot mutate those rows; include separate throws_ok checks for INSERT, UPDATE, DELETE per table/view and increment the pgTAP plan number accordingly.docs/contributing/billing.md-93-93 (1)
93-93: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winLink points outside
/docs— violates docs guideline.
[test/billing-*.md](../../test/)resolves to/test/, which is outside thedocs/tree. Per the docs guideline, paths outside/docsmust be referenced as inline code, not links.♻️ Proposed fix
-User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases: [`test/billing-*.md`](../../test/). +User-facing billing docs: [`docs/platform/billing.mdx`](../platform/billing.mdx). Behaviour test cases: `test/billing-*.md`.As per coding guidelines: "Never link outside
/docsfrom docs markdown files; reference external paths as inline code instead".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/contributing/billing.md` at line 93, In docs/contributing/billing.md the markdown link [`test/billing-*.md`](../../test/) points outside /docs and must be converted to inline code; replace the link token [`test/billing-*.md`](../../test/) with an inline code span such as `../../test/` or ``test/billing-*.md`` so the external /test path is referenced as code rather than a clickable link.docs/platform/billing.mdx-18-29 (1)
18-29:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThis page documents multi-seat billing that v1 does not ship.
PR
#705is explicitly single-seat only, but this copy promises per-seat subscriptions, automatic seat scaling, invite/remove proration, and pooled credits that grow with headcount. That will set the wrong expectation for customers and support. Please rewrite these sections to describe the shipped quantity=1behavior and call multi-seat/proration out as a future limitation instead.Also applies to: 91-100, 150-169
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/platform/billing.mdx` around lines 18 - 29, The billing doc currently describes multi-seat features (per-seat pricing, pooled credits, automatic seat scaling, proration) but PR `#705` ships single-seat only; update the copy to state the shipped behavior is quantity=`1` (solo single-seat subscriptions), remove or reword definitive language such as "Pro and Team are both seat-based." and "AI credit pools at the **organization** level" to instead note per-seat/pooled credits, proration, and auto-scaling are future features/limitations, and add one clear sentence calling out that multi-seat billing, invite/remove proration, and pooled credits are not available in v1 and will be addressed in a future release; ensure references to annual discounts remain accurate but do not imply multi-seat proration is supported.editor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx-17-25 (1)
17-25:⚠️ Potential issue | 🟠 Major | ⚡ Quick winEnforce org ownership before showing the billing upgrade flow.
This only checks that the user is signed in, then loads any organization by slug. If the
organizationrow is readable to non-owners, another logged-in user can reach another org’s billing upgrade page and potentially trigger billing actions for it. Please scope this query byowner_id = auth.user.idor perform an explicit membership/role check before renderingUpgradeView.Suggested guard
- const { data: org } = await client + const { data: org } = await client .from("organization") - .select("id, name") + .select("id, name, owner_id") .eq("name", organization_name) .single(); if (!org) return notFound(); + if (org.owner_id !== auth.user.id) return notFound(); return <UpgradeView orgId={org.id} orgName={org.name} />;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsx around lines 17 - 25, The org fetch currently only filters by slug (organization_name) before rendering UpgradeView; tighten this by ensuring the requesting user is the org owner or has appropriate billing role: modify the query built from client.from("organization").select(...) to also filter by owner_id = auth.user.id (or the equivalent current user id variable) or, alternatively, perform an explicit membership/role check against your membership table (e.g., organization_membership where user_id = auth.user.id and role IN ('owner','billing')) before returning <UpgradeView orgId={org.id} orgName={org.name} />; ensure you reference the same symbols (organization_name, client.from("organization").select, UpgradeView) so only authorized users can reach the billing flow.editor/types/types.ts-118-125 (1)
118-125:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
planshould not be required on a type you describe as conditional.The docblock states this field is only present on responses that went through the workspace API path, but
OrganizationWithAvatarmakes it mandatory everywhere. This creates a type-soundness issue whereorganization.planappears safe to access but may be undefined at runtime. Either split this into a billing-specific response type or makeplanoptional and narrow it where the API guarantees it.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/types/types.ts` around lines 118 - 125, The OrganizationWithAvatar type marks the conditional billing field plan (PlanTier) as required even though the docblock says it only exists on certain API responses; change this by making plan optional (plan?: PlanTier | undefined) or by creating a separate billing-specific type (e.g., OrganizationWithAvatarAndPlan extends OrganizationWithAvatar { plan: PlanTier }) and use that narrower type where the workspace API guarantees the field; update any call sites that assume organization.plan to either narrow the type (type guard or runtime check) or use the billing-specific response type (refer to OrganizationWithAvatar and PlanTier to locate the declaration and adjust consumers accordingly).editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx-505-509 (1)
505-509:⚠️ Potential issue | 🟠 Major | ⚡ Quick winGate the sandbox disclaimer behind an explicit test-mode signal.
This note renders for every organization, so live customers will be told their card is never billed. That is materially false outside billing test mode.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx around lines 505 - 509, The sandbox disclaimer paragraph (the <p> after Separator in _view.tsx) must be rendered only when the organization is in billing test mode; wrap that paragraph (or the whole block) in a conditional that checks an explicit test-mode signal (e.g., organization.billingTestMode || organization.isBillingTestMode || a prop/hook like isBillingTestMode) so live organizations don't see the message, and handle possible undefined organization/billing fields safely (optional chaining or null checks); update any callers/props or fetch logic to supply the test-mode flag if missing and add/update a small test to assert the disclaimer only appears when that flag is true.editor/lib/billing/index.ts-274-308 (1)
274-308:⚠️ Potential issue | 🟠 Major | ⚡ Quick win
resolveOrCreateStripeCustomer()can leak orphan Stripe customers.The Stripe customer is created before the attach path is known to have succeeded. If the DB call fails, or two requests race past the initial cache miss, one of those external customers is dropped on the floor and every retry can mint another one.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/lib/billing/index.ts` around lines 274 - 308, resolveOrCreateStripeCustomer currently creates a Stripe customer before ensuring the DB attach succeeded, which can leak orphan customers and race; fix by re-checking getCustomerId(org_id) immediately after the initial cache miss and before calling stripe.customers.create to avoid races, and if you still create a Stripe customer then call service_role.workspace.rpc("fn_billing_attach_stripe_customer", ...) but ensure that on any attach failure you call stripe.customers.del(created.id) (or schedule deletion) to clean up the newly created customer; update resolveOrCreateStripeCustomer to perform the double-check-before-create and the cleanup-on-attach-failure around stripe.customers.create and the fn_billing_attach_stripe_customer RPC.editor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsx-232-257 (1)
232-257:⚠️ Potential issue | 🟠 Major | ⚡ Quick winShow the error state before the skeleton gate.
When
getBillingSummary()fails on first load,statestaysnullanderris set, butif (loading || !state)still returns the skeleton. That makes the retry UI unreachable and the page looks stuck loading forever.💡 Minimal fix
- if (loading || !state) { + if (loading) { return ( <main className="container mx-auto py-10 max-w-4xl"> <header className="mb-8"> <h1 className="text-3xl font-bold">Billing</h1> </header> @@ - if (err) { + if (err && !state) { return ( <main className="container mx-auto py-10 max-w-4xl"> <h1 className="text-3xl font-bold mb-4">Billing</h1> <p className="text-destructive">Failed to load billing: {err}</p> <Button onClick={refresh} className="mt-4" variant="outline">🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx around lines 232 - 257, The error UI is currently unreachable because the skeleton gate (if (loading || !state)) runs before checking err; change the rendering order so the err check runs before the skeleton check (i.e., render the error return when err is truthy) or modify the skeleton condition to exclude error (e.g., if (loading || (!state && !err))). Update the component logic around loading, state, and err (references: getBillingSummary(), refresh, loading, state, err) so the Retry button and error message render when getBillingSummary() fails on first load.supabase/schemas/grida_billing.sql-612-643 (1)
612-643:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon't collapse first-invoice failures into
past_due.This handler always writes
past_due, but the rest of the PR distinguishes first-payment failures asincomplete/incomplete_expired. Stripe retains newly-created subscriptions inincompletestatus initially (for ~23 hours) when the first invoice payment fails, transitioning toincomplete_expiredif unpaid—it never usespast_duefor first-payment failures. The current code unconditionally overwrites these states, causing the final status to become webhook-order dependent and the UI can fall back to the renewal-failure path for a brand-new subscription.Mitigation
- UPDATE grida_billing.subscription - SET status = 'past_due', updated_at = now() - WHERE stripe_subscription_id = v_sub_id; + UPDATE grida_billing.subscription + SET status = CASE + WHEN status IN ('incomplete', 'incomplete_expired') THEN status + ELSE 'past_due' + END, + updated_at = now() + WHERE stripe_subscription_id = v_sub_id;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/schemas/grida_billing.sql` around lines 612 - 643, The handler currently unconditionally sets subscription.status to 'past_due' for invoice.payment_failed; instead detect first-invoice failures using v_attempt_count (and/or the current subscription row) and preserve or set 'incomplete'/'incomplete_expired' as appropriate: SELECT status INTO v_current_status FROM grida_billing.subscription WHERE stripe_subscription_id = v_sub_id (reuse v_org_id lookup), then if v_current_status = 'incomplete' leave it unchanged (or if v_attempt_count IS NULL or = 0 set status to 'incomplete'), otherwise set status = 'past_due'; also write the same computed status into the INSERT to grida_billing.audit and keep v_handler logic (v_handler := 'invoice_payment_failed' / 'invoice_payment_failed_skipped') consistent. Ensure you reference v_attempt_count, v_sub_id, v_org_id, v_current_status and the grida_billing.subscription update/insert statements when making the change.supabase/migrations/20260506132900_grida_billing.sql-794-805 (1)
794-805: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick winUse a safe
search_pathin the SECURITY DEFINER wrappers.These public wrappers run as
SECURITY DEFINERwithsearch_path = grida_billing, public, pg_temp. That is the unsafe pattern the repo rules explicitly ban;pg_catalogshould be first, andpg_tempshould not be in the definer path. The same fix applies to the otherpublic.fn_billing_*wrappers below.Suggested fix
-SET search_path = grida_billing, public, pg_temp +SET search_path = pg_catalog, publicAs per coding guidelines, "For SECURITY DEFINER functions, set a safe search_path with
SET search_path = pg_catalog, public".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 794 - 805, The SECURITY DEFINER wrapper public.fn_billing_apply_stripe_event (and the other public.fn_billing_* wrappers) uses an unsafe search_path; change the SET search_path clause to a safe value by setting "SET search_path = pg_catalog, public" (remove pg_temp and ensure pg_catalog is first) so the wrapper still calls grida_billing.fn_apply_stripe_event but runs with a safe definer search_path.editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-318-326 (1)
318-326:⚠️ Potential issue | 🟠 Major | ⚡ Quick winBlock plan changes for non-healthy subscriptions.
getActivePaidSubscription()returns any non-canceled paid row, so this path still openssubscription_update_confirmforpast_due,unpaid,incomplete, and similar states. That contradicts the PR requirement to disable plan changes until payment issues are resolved.Suggested fix
const sub = await getActivePaidSubscription(org_id); if (!sub) { throw new BillingError( "No active paid subscription to change. Upgrade first.", "not_subscribed", 400, "billing/upgrade" ); } + if (sub.status !== "active" && sub.status !== "trialing") { + throw new BillingError( + "Resolve the current billing issue before changing plans.", + "plan_change_blocked", + 409, + "billing" + ); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts around lines 318 - 326, getActivePaidSubscription currently returns any non-canceled paid row, so before allowing plan-change flow (the branch that would open subscription_update_confirm) validate the subscription health by checking the returned sub.status (from getActivePaidSubscription) against allowed healthy states (e.g., "active") or reject known bad states ("past_due","unpaid","incomplete", etc.); if the status is not healthy, throw the existing BillingError (the same class used here) with a payment-issue message and appropriate code so plan changes are blocked until payment is resolved. Ensure you update the branch that currently proceeds after const sub = await getActivePaidSubscription(org_id) to perform this status check and throw early when unhealthy.supabase/migrations/20260506132900_grida_billing.sql-502-513 (1)
502-513:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail closed when a Stripe price is unmapped.
Falling back to
"pro"here will silently mis-project any unknown or newly added price as Pro. That writes the wrong plan into the local source of truth and can under/over-entitle the org until someone fixes it manually.Suggested fix
- v_plan := 'pro'; + v_plan := NULL; IF v_price_id IS NOT NULL THEN SELECT CASE WHEN id IN ('plan.team', 'plan.team.annual') THEN 'team' WHEN id IN ('plan.pro', 'plan.pro.annual') THEN 'pro' - ELSE 'pro' + ELSE NULL END INTO v_plan FROM grida_billing.product_catalogue WHERE stripe_price_id = v_price_id; - v_plan := coalesce(v_plan, 'pro'); END IF; + IF v_plan IS NULL THEN + RAISE EXCEPTION 'subscription % has unknown stripe price %', v_sub_id, v_price_id; + END IF;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 502 - 513, The current logic in the block around v_plan, v_price_id and the grida_billing.product_catalogue lookup silently falls back to 'pro' for unmapped stripe_price_id values; change it to fail-fast: after attempting the SELECT ... INTO v_plan from product_catalogue (matching on stripe_price_id), check whether a row was FOUND or whether v_plan is NULL and if so RAISE EXCEPTION (including the v_price_id for context) instead of coalescing to 'pro'; this keeps the incorrect default from being written and surfaces unmapped/new Stripe prices for explicit handling.supabase/migrations/20260506132900_grida_billing.sql-353-370 (1)
353-370:⚠️ Potential issue | 🟠 Major | ⚡ Quick winDon’t return
attached=truewhen no account row was updated.If provisioning failed or
p_org_idis invalid,SELECT ... FOR UPDATEfinds no row, theUPDATEaffects 0 rows, and this function still reports success and writes an audit entry. Callers will believe the customer is cached even thoughgrida_billing.accountis unchanged.Suggested fix
SELECT acc.stripe_customer_id INTO v_existing FROM grida_billing.account acc WHERE acc.organization_id = p_org_id FOR UPDATE; + + IF NOT FOUND THEN + RAISE EXCEPTION 'billing account not provisioned for organization %', p_org_id; + END IF; IF v_existing IS NULL THEN UPDATE grida_billing.account SET stripe_customer_id = p_stripe_customer_id, updated_at = now() WHERE organization_id = p_org_id;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 353 - 370, The function currently sets v_attached = true and writes an audit even when no account row was actually updated; after the UPDATE on grida_billing.account (the statement that uses WHERE organization_id = p_org_id) capture whether the UPDATE affected a row (use IF FOUND or GET DIAGNOSTICS ROW_COUNT into a local var) and only set v_attached := true and INSERT into grida_billing.audit when the UPDATE affected 1 row; if no row was updated, leave v_attached false (or return an error) and do not write the audit. Ensure you reference and check v_existing, p_org_id and p_stripe_customer_id around that UPDATE/INSERT block.supabase/migrations/20260506132900_grida_billing.sql-442-446 (1)
442-446:⚠️ Potential issue | 🟠 Major | ⚡ Quick winLock the
stripe_eventrow before the replay check.Two concurrent deliveries of the same event can both read
processed_at IS NULLhere and execute the projector body, so idempotency only holds after the first commit. That leaves duplicate audit rows and makes the webhook ingest contract racey.Suggested fix
- SELECT * INTO v_existing FROM grida_billing.stripe_event WHERE id = p_event_id; + SELECT * + INTO v_existing + FROM grida_billing.stripe_event + WHERE id = p_event_id + FOR UPDATE;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@supabase/migrations/20260506132900_grida_billing.sql` around lines 442 - 446, The race comes from reading grida_billing.stripe_event into v_existing without locking, so two concurrent deliveries can see processed_at IS NULL and both proceed; change the fetch to acquire a row lock (SELECT ... FOR UPDATE) on grida_billing.stripe_event for p_event_id before checking v_existing.processed_at so the second transaction will wait and observe the committed processed_at; update the SELECT INTO that populates v_existing (and any related reads) to use FOR UPDATE within the same function/transaction that handles the projector.
🟡 Minor comments (6)
editor/.env.test-11-15 (1)
11-15:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPrecedence comment is incorrect
The comment in
.env.teststates thatprocess.env(from your shell) has the lowest priority (#1), overridden by.env.test.local(#3). However,vitest.config.tsexplicitly states: "Existing process.env always wins."The
loadEnvFile()function only sets variables that don't already exist inprocess.env(if (!(key in process.env))), making shell-injected env vars have the highest priority, not the lowest.Update the comment to reflect actual behavior: existing
process.envvalues are never overridden by either.env.testor.env.test.local.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/.env.test` around lines 11 - 15, Update the inaccurate precedence comment in .env.test to reflect actual behavior: existing process.env values are never overridden by env files. Mention that vitest.config.ts's loadEnvFile() (which checks if (!(key in process.env))) gives shell-injected variables highest priority, and that .env.test and .env.test.local only set variables when not already present (with .env.test.local remaining for per-developer secrets but still not overriding process.env).editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx-37-43 (1)
37-43:⚠️ Potential issue | 🟡 Minor | ⚡ Quick win"Billing" and "Upgrade plan" are both highlighted simultaneously on the upgrade page.
When
pathnameis/organizations/{org}/settings/billing/upgrade, thestartsWithcheck at line 71 marks "Billing" as active (because/billing/upgradestarts with/billing/), and "Upgrade plan" is also active via the exact-match. Two items light up at once.Fix by using exact matching for "Billing" only when sub-items are visible, or by excluding child paths explicitly:
🐛 Proposed fix
const isActive = - pathname === c.href || pathname?.startsWith(`${c.href}/`); + pathname === c.href || + (pathname?.startsWith(`${c.href}/`) && + !categories.some((other) => other !== c && other.href.startsWith(`${c.href}/`) && pathname?.startsWith(other.href)));Or the simpler, explicit approach — change the "Billing" entry to use exact match only since "Upgrade plan" already covers the sub-path:
{categories.map((c) => { const isActive = - pathname === c.href || pathname?.startsWith(`${c.href}/`); + c.href === `${settingsBase}/billing` + ? pathname === c.href + : pathname === c.href || pathname?.startsWith(`${c.href}/`);Also applies to: 69-79
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/_shell.tsx around lines 37 - 43, The "Billing" menu item in the categories array is being marked active alongside its child "Upgrade plan" because the active check uses startsWith on pathname; update the active resolution so parent items like the Billing entry (in the categories const in _shell.tsx) use exact matching when a child route exists: either change the Billing entry to be matched exactly instead of via startsWith, or modify the active detection logic to prefer exact path equality for parent items when any explicit child path (e.g., `${settingsBase}/billing/upgrade`) is present so only the intended item is highlighted.editor/vitest.config.ts-7-8 (1)
7-8:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winPrecedence comment has the order backwards.
The code gives
process.env(shell) the highest priority (if (!(key in process.env))), so the true order isshell > .env.test.local > .env.test. The comment currently lists shell as the lowest priority, which will mislead developers who expect their.env.test.localvalues to override a shell-exported variable.📝 Suggested correction
-// Precedence (later loses): -// .env.test.local > .env.test > shell. Existing process.env always wins. +// Precedence (highest wins): +// shell > .env.test.local > .env.test. Existing process.env always wins.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/vitest.config.ts` around lines 7 - 8, The precedence comment is backwards: update the explanatory comment that starts with "without `set -a; . ./.env.test.local` ceremony. Precedence (later loses):" so it correctly describes that existing process.env (shell) currently wins due to the `if (!(key in process.env))` check; change the order to "shell > .env.test.local > .env.test" (or reword to say "existing process.env takes precedence over .env.test.local which takes precedence over .env.test") so the comment matches the actual behavior implemented by that conditional.docs/platform/billing.mdx-1-6 (1)
1-6:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winAdd
keywordsto the frontmatter.This page is under
docs/platform/**and already hastitle/description, so it is missing the remaining required SEO field. As per coding guidelines,docs/{wg,reference,editor,forms,platform,with-figma,design,math}/**/*.{md,mdx}: Add SEO frontmatter withtitle,description, andkeywordswhen adding or meaningfully editing actively maintained doc pages.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/platform/billing.mdx` around lines 1 - 6, The frontmatter in the Billing doc is missing the required SEO `keywords` field; update the YAML frontmatter that currently contains `title:`, `description:` (as shown in the file header) to include a `keywords:` entry with a comma-separated list of relevant SEO terms (e.g., "billing, pricing, AI credits, plans") so the page complies with the docs frontmatter rule for docs/platform pages.docs/platform/billing.mdx-193-194 (1)
193-194:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winKeep docs links inside
/docs.
/support/refund-policyis an internal route outside the docs tree, so this link breaks the repository rule for markdown docs. Point to the docs version of that policy instead, or reference the non-doc path as inline code. As per coding guidelines,docs/**/*.{md,mdx}: Never link outside/docsfrom docs markdown files; reference external paths as inline code instead.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/platform/billing.mdx` around lines 193 - 194, The link in the "Can I get a refund?" section points to an internal non-doc route (/support/refund-policy) which violates the docs rule; update the link to point to the docs-hosted policy path (e.g., the docs equivalent under /docs/... or the canonical docs route for the refund policy) or, if you must reference the non-doc path, replace the markdown link with inline code notation for /support/refund-policy so it no longer creates an external docs link.editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts-596-603 (1)
596-603:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winThe audit cursor can skip rows at page boundaries.
The result set is ordered by
(created_at DESC, id DESC), but the next page only filters oncreated_at < cursor. Any rows that share the boundary timestamp become unreachable. Use a composite cursor(created_at, id)or switch the cursor to the monotonicid.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts around lines 596 - 603, The current pagination uses only created_at as the cursor in the query built in the block that sets `if (cursor) q = q.lt("created_at", cursor);` and computes `next_cursor` from `rows[rows.length - 1].created_at`, which can skip rows that share the same timestamp; change the cursor to a composite cursor of (created_at, id) or use the monotonic `id` only: update the query building (the `q` construction) to accept a composite cursor and apply a predicate equivalent to (created_at < cursor.created_at OR (created_at = cursor.created_at AND id < cursor.id)), update how `cursor` is passed/parsed to include both `created_at` and `id`, and set `next_cursor` to include both values (e.g., the last row's created_at and id) so page boundaries are deterministic and no rows are skipped; alternatively, replace the created_at filter entirely with `id < cursorId` and compute `next_cursor` from the last row's id if you prefer a monotonic id cursor.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: fc2c749e-3480-4c21-9369-8425196b12e1
⛔ Files ignored due to path filters (1)
pnpm-lock.yamlis excluded by!**/pnpm-lock.yaml
📒 Files selected for processing (58)
.gitignoredatabase/database-generated.types.tsdocs/contributing/billing.mddocs/platform/billing.mdxeditor/.env.testeditor/.oxlintrc.jsonceditor/app/(api)/private/webhooks/stripe/route.tseditor/app/(api)/private/workspace/[organization_id]/route.tseditor/app/(insiders)/insiders/auth/basic/sign-in/route.tseditor/app/(site)/organizations/[organization_name]/settings/_shell.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/@modal/(.)upgrade/page.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/@modal/default.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/_actions.tseditor/app/(site)/organizations/[organization_name]/settings/billing/_modal-shell.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/layout.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/page.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/page.tsxeditor/app/(site)/organizations/[organization_name]/settings/layout.tsxeditor/app/(site)/organizations/[organization_name]/settings/profile/page.tsxeditor/app/(workspace)/universal/[[...path]]/page.tsxeditor/app/(www)/(pricing)/pricing/_sections/faq.tsxeditor/app/(www)/(pricing)/pricing/page.tsxeditor/host/url.tseditor/k/labels.tseditor/lib/billing/__tests__/e2e/README.mdeditor/lib/billing/__tests__/e2e/fixtures/db.tseditor/lib/billing/__tests__/e2e/fixtures/deliver-event.tseditor/lib/billing/__tests__/e2e/fixtures/org.tseditor/lib/billing/__tests__/e2e/fixtures/safety.tseditor/lib/billing/__tests__/e2e/scenarios/idempotency.test.tseditor/lib/billing/__tests__/e2e/scenarios/lifecycle.test.tseditor/lib/billing/__tests__/e2e/scenarios/tampered-signature.test.tseditor/lib/billing/index.tseditor/lib/billing/marketing-plans.tseditor/lib/billing/plans.tseditor/lib/supabase/server.tseditor/package.jsoneditor/scaffolds/workspace/sidebar.tsxeditor/scaffolds/workspace/workspace.tsxeditor/scripts/billing/setup-stripe-test.tseditor/tsconfig.jsoneditor/types/types.tseditor/vitest.config.tseditor/www/data/pricing.tseditor/www/pricing/pricing-comparison-table.tsxeditor/www/pricing/pricing.tsxlefthook.ymlsupabase/migrations/20260506132900_grida_billing.sqlsupabase/migrations/20260506132901_drop_legacy_pricing_tier.sqlsupabase/schemas/grida_billing.sqlsupabase/seed.sqlsupabase/tests/test_grida_billing_test.sqltest/billing-payment-and-money-safety.mdtest/billing-quota-and-ai.mdtest/billing-subscription-and-stripe.mdtest/billing-ux-and-edge-cases.md
| if (await getActivePaidSubscription(org_id)) { | ||
| throw new BillingError( | ||
| "Organization already has an active paid subscription.", | ||
| "already_subscribed", | ||
| 409 | ||
| ); | ||
| } | ||
|
|
||
| const origin = await getOrigin(); | ||
| const success_url = assertAllowedRedirect(params.success_url, origin); | ||
| const cancel_url = assertAllowedRedirect(params.cancel_url, origin); | ||
|
|
||
| const id = price_catalogue_id(params.plan, interval); | ||
| const cat = await getCatalogueStripeIds(id); | ||
| if (!cat) { | ||
| throw new BillingError( | ||
| `Stripe price for ${id} is not yet wired.`, | ||
| "billing_not_provisioned", | ||
| 500 | ||
| ); | ||
| } | ||
|
|
||
| // v1 ships single-seat only. Multi-seat billing is deferred — when it | ||
| // lands, the quantity will come from a "Manage seats" UI that pushes | ||
| // through Stripe, never inferred from member count here. | ||
| const quantity = 1; | ||
|
|
||
| const customer = await resolveOrCreateStripeCustomer(org_id); | ||
| const idempotencyKey = `subscribe:${org_id}:${params.plan}:${interval}:${Math.floor(Date.now() / 60000)}`; | ||
|
|
||
| const session = await stripe.checkout.sessions.create( | ||
| { | ||
| mode: "subscription", | ||
| customer, | ||
| line_items: [{ price: cat.stripe_price_id, quantity }], | ||
| subscription_data: { | ||
| metadata: { | ||
| grida_organization_id: String(org_id), | ||
| grida_plan: params.plan, | ||
| grida_interval: interval, | ||
| }, | ||
| }, | ||
| success_url, | ||
| cancel_url, | ||
| metadata: { | ||
| grida_organization_id: String(org_id), | ||
| kind: "subscribe", | ||
| plan: params.plan, | ||
| interval, | ||
| }, | ||
| allow_promotion_codes: true, | ||
| }, | ||
| { idempotencyKey } | ||
| ); |
There was a problem hiding this comment.
Checkout creation still allows duplicate live subscriptions.
This only checks local state before creating the Checkout Session. If two startSubscribeCheckout() calls happen before the first completion is projected, both sessions can complete in Stripe. The second webhook will then hit the single-active-subscription constraint added in supabase/migrations/20260506132900_grida_billing.sql Lines 88-90, leaving an extra live Stripe subscription billing the customer while the projector rejects it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_actions.ts
around lines 425 - 478, The checkout creation can race because it only checks
local DB via getActivePaidSubscription; before calling
stripe.checkout.sessions.create you must also check the live Stripe customer to
prevent duplicate active subscriptions: after
resolveOrCreateStripeCustomer(org_id) call stripe.subscriptions.list({ customer,
status: "active" }) (and include "trialing" if you treat trials as active) and
throw the same BillingError if any live subscriptions exist; alternatively
create a short-lived reservation row or DB lock keyed by org_id inside
startSubscribeCheckout to prevent concurrent session creation—apply the Stripe
check (or reservation/lock) before calling stripe.checkout.sessions.create and
keep the existing idempotencyKey logic.
Projector hardening (SQL): - Fail-closed mapping for unknown Stripe price ids — refuse to default to 'pro' - SELECT FOR UPDATE on stripe_event so concurrent deliveries serialise - IF NOT FOUND in fn_attach_stripe_customer (refuse silent "attached") - Preserve incomplete*/incomplete_expired in invoice.payment_failed handler - Safe SECURITY DEFINER search_path = pg_catalog, public Server actions: - Server-side guard rejects plan-change while past_due/incomplete/paused - Stripe customer create now uses idempotencyKey + post-create recheck - Composite (created_at, id) cursor on listBillingAudit - New resumeSubscription server action: undoes cancel_at_period_end without charge, projects via webhook (no Portal flow_data exists for this) UI: - Dedicated /billing/return callback page owns post-Stripe-flow polling + redirect. Fullscreen loading spinner only; main billing page no longer watches ?subscribe=success or runs the polling loop - Cancel moved to bottom-of-page Danger zone; primary CTAs stay clean - Resume subscription button surfaces inline when cancel_at_period_end=true. Optimistic UI flip; webhook reconciles - Annual price label includes monthly equivalent ($192/yr (~$16/mo)) - First-load error renders before skeleton gate so Retry is reachable - Sandbox disclaimer gated on BILLING_TEST_MODE - Settings sidebar fixes double-active when on /upgrade Marketing/docs (single-seat v1 reality): - Strip per-seat language from pricing tiles, FAQ, billing.mdx examples - Disable "Buy extra credits" advert until top-up flow ships - SEO frontmatter keywords on billing.mdx; fix dead refund-policy link Tooling/tests: - Fix catalogueId import in setup-stripe-test.ts (renamed price_catalogue_id) - Org-scoped /_/settings collision: drop ambiguous org settings shorthand - buildUniversalDestination throws on missing project/document context - E2E fixtures: rethrow Stripe cleanup failures, read body as text first - pgTAP wires fn_billing_setup_product before subscription.created tests - Env precedence comments fixed (highest priority wins) Known issue documented: - TC-BILLING-SUB-059: concurrent checkout sessions can produce duplicate live Stripe subs. Risk is to Grida (manual refund), not customer. Closure tracked in GRIDA-60.
There was a problem hiding this comment.
Actionable comments posted: 9
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
test/billing-subscription-and-stripe.md (1)
1-355: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy liftThis is too broad for a single manual test case in
test/.Most of this file is billing/spec logic rather than UX behavior that needs human interaction, and it bundles dozens of independent behaviors into one case. Please move the non-UX material to docs/specs and keep
test/*.mdfor discrete interaction-heavy manual cases only.As per coding guidelines, "
test/**/*.md: Add manual test cases to thetestdirectory only for UX bugs requiring human interaction verification ... not for pure logic, math, or data transformations" and "Ensure each manual test case file covers only one independent behavior".🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/billing-subscription-and-stripe.md` around lines 1 - 355, The test file bundles dozens of independent billing specs (e.g. TC-BILLING-SUB-001 ... TC-BILLING-SUB-059) and is too broad for a single manual test; split it by responsibility: move pure-spec/math/data sections (all non-UI invariants and lifecycle rules, e.g. TC-BILLING-SUB-002..TC-BILLING-SUB-056, the "Seats" and "Stripe consistency" sections) into a docs/specs billing doc, and keep only discrete, UX/manual cases (those requiring human interaction like Customer Portal flows: TC-BILLING-SUB-001, TC-BILLING-SUB-004, TC-BILLING-SUB-007, TC-BILLING-SUB-008, TC-BILLING-SUB-010, TC-BILLING-SUB-011, etc.) as separate small files under test/, one behavior per file named by the TC ID; ensure each test/*.md contains a single Expected/Steps block and update tags/status accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/contributing/billing.md`:
- Line 1: The Markdown file with the heading "Contributing to Grida | Billing"
is missing YAML frontmatter; add a frontmatter block at the top containing at
minimum "format: md" (e.g., ---\nformat: md\n---) so the document opts out of
MDX parsing; ensure the frontmatter appears before the existing "# Contributing
to Grida | Billing" heading and contains no JSX/MDX content.
- Line 1: The "Contributing to Grida | Billing" page lives in an unmaintained
docs location; move the page titled "Contributing to Grida | Billing" into an
actively maintained docs subdirectory (such as the project’s wg or reference
docs area), update any sidebars/navigation or cross-links that referenced the
old location, and ensure frontmatter/metadata (title, permalink) if present
reflects the new location so build/navigation continue to work.
In `@docs/platform/billing.mdx`:
- Around line 90-93: Update the overstated "the same minute the charge fails"
claim in the billing doc: locate the bullet that starts "Your subscription
pauses immediately. Not in 24 hours, not after a 7-day grace period — the same
minute the charge fails." and replace it with softened wording such as "Your
subscription is paused shortly after the charge fails (typically within a few
minutes)," and add a brief note that the system is webhook-driven (Stripe →
/api/billing/webhook → projector) so delivery can take seconds to a few minutes;
ensure the adjacent bullets (monthly AI credit suspended, top-up credit,
retries) remain consistent with this timing change.
In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/_view.tsx:
- Around line 375-380: The current code nests <Button> inside <Link>, creating
invalid interactive nesting; instead render the link as the button's child by
using the Button's "asChild" pattern: replace the <Link
href={`${baseUrl}/upgrade`}><Button ...>...</Button></Link> wrapper with <Button
asChild variant={isPaid ? "outline" : "default"}><Link
href={`${baseUrl}/upgrade`}>{isPaid ? "Adjust plan" :
"Upgrade"}</Link></Button>, keeping the same baseUrl and isPaid logic so the
visual button remains but the actual interactive element is the anchor.
In
`@editor/app/`(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsx:
- Around line 265-279: The card currently marks a plan as "Current" based only
on plan id (isCurrent = current?.plan === plan.id) which ignores the selected
billing interval; change the logic to consider the interval or adjust the badge
text: either compute a stricter matcher (e.g., isExactCurrent = current?.plan
=== plan.id && current?.interval === interval) and use that to render the Badge,
or keep the plan match but render the Badge as "Current — Monthly" / "Current —
Annual" using the existing interval variable so the badge reflects the selected
interval; update the JSX around isCurrent / Badge in the component to use these
new checks/text (refer to isCurrent, current?.plan, interval, and the Badge/
Card JSX).
In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts`:
- Around line 88-96: The catch block in teardownOrg currently does "if (/No such
customer|resource_missing/i.test(msg)) return;" which aborts teardown early and
skips the local organization deletion; change this so missing-customer errors
are ignored but do not return from teardownOrg (e.g., remove the return and
optionally log/continue), ensuring the subsequent local organization delete
logic still runs after stripe.customers.del fails with resource_missing; update
the error handling around stripe.customers.del to only throw for unexpected
errors and allow execution to proceed to the organization deletion code path.
In `@supabase/schemas/grida_billing.sql`:
- Around line 733-745: fn_stamp_failure currently only updates an existing
grida_billing.stripe_event row, so when fn_apply_stripe_event inserts the row
and that transaction rolls back the UPDATE matches nothing; change
fn_stamp_failure to perform an UPSERT (insert ... on conflict do update) in its
own transaction so it can record a forensic failure regardless of caller
rollback, and accept/require the event type (add parameter p_event_type) so the
inserted row has necessary type metadata; update the caller
fn_apply_stripe_event to pass the event type when invoking fn_stamp_failure and
ensure fn_stamp_failure uses a transaction-independent context (e.g., LANGUAGE
plpgsql SECURITY DEFINER) to run the upsert.
- Around line 366-378: The code currently silently ignores attempts to bind a
different stripe_customer_id by updating grida_billing.account only when
v_existing IS NULL and otherwise proceeding; change this to fail closed by
detecting mismatches between v_existing and p_stripe_customer_id (the existing
stripe_customer_id vs the incoming p_stripe_customer_id) and return an
error/raise exception instead of no-oping; apply the same guard in the
customer.created/customer.updated projector branch where you currently mark the
event handled—if v_existing IS NOT NULL and v_existing <> p_stripe_customer_id
then RAISE/RETURN an error (or set an error state) rather than continuing, and
include this check around the same variables (v_existing, p_stripe_customer_id)
before inserting into grida_billing.audit.
In `@supabase/tests/test_grida_billing_test.sql`:
- Around line 26-31: Add pgTAP assertions that explicitly verify RLS/permission
denials for the internal tables grida_billing.account,
grida_billing.subscription, grida_billing.product_catalogue,
grida_billing.stripe_event, and grida_billing.audit: for each table add tests
that impersonate an anonymous role and an authenticated non-privileged role and
assert that SELECT is denied, and similarly assert INSERT, UPDATE, and DELETE
are denied (or allowed only where intended). Use the same test harness style as
the existing file (e.g., role-switching helper or SET ROLE/SET LOCAL and pgTAP
ok/fails assertions) to cover read isolation and write isolation for each table
so privilege drift on the internal surface is caught.
---
Outside diff comments:
In `@test/billing-subscription-and-stripe.md`:
- Around line 1-355: The test file bundles dozens of independent billing specs
(e.g. TC-BILLING-SUB-001 ... TC-BILLING-SUB-059) and is too broad for a single
manual test; split it by responsibility: move pure-spec/math/data sections (all
non-UI invariants and lifecycle rules, e.g.
TC-BILLING-SUB-002..TC-BILLING-SUB-056, the "Seats" and "Stripe consistency"
sections) into a docs/specs billing doc, and keep only discrete, UX/manual cases
(those requiring human interaction like Customer Portal flows:
TC-BILLING-SUB-001, TC-BILLING-SUB-004, TC-BILLING-SUB-007, TC-BILLING-SUB-008,
TC-BILLING-SUB-010, TC-BILLING-SUB-011, etc.) as separate small files under
test/, one behavior per file named by the TC ID; ensure each test/*.md contains
a single Expected/Steps block and update tags/status accordingly.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 3eefb340-afea-42e7-b0a0-3472b03f6357
📒 Files selected for processing (22)
docs/contributing/billing.mddocs/platform/billing.mdxeditor/.env.testeditor/app/(site)/organizations/[organization_name]/settings/_shell.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/_actions.tseditor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/return/_view.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/return/page.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsxeditor/app/(www)/(pricing)/pricing/_sections/faq.tsxeditor/host/url.tseditor/lib/billing/__tests__/e2e/fixtures/deliver-event.tseditor/lib/billing/__tests__/e2e/fixtures/org.tseditor/lib/billing/index.tseditor/lib/billing/marketing-plans.tseditor/scripts/billing/setup-stripe-test.tseditor/vitest.config.tseditor/www/data/pricing.tssupabase/migrations/20260506132900_grida_billing.sqlsupabase/schemas/grida_billing.sqlsupabase/tests/test_grida_billing_test.sqltest/billing-subscription-and-stripe.md
✅ Files skipped from review due to trivial changes (4)
- editor/.env.test
- editor/app/(site)/organizations/[organization_name]/settings/_shell.tsx
- editor/scripts/billing/setup-stripe-test.ts
- supabase/migrations/20260506132900_grida_billing.sql
🚧 Files skipped from review as they are similar to previous changes (7)
- editor/vitest.config.ts
- editor/lib/billing/tests/e2e/fixtures/deliver-event.ts
- editor/lib/billing/marketing-plans.ts
- editor/www/data/pricing.ts
- editor/host/url.ts
- editor/lib/billing/index.ts
- editor/app/(site)/organizations/[organization_name]/settings/billing/_actions.ts
| @@ -0,0 +1,115 @@ | |||
| # Contributing to Grida | Billing | |||
There was a problem hiding this comment.
Add required Markdown frontmatter with format: md.
For .md docs files, include frontmatter and set format: md to opt out of MDX parsing.
As per coding guidelines, "For files that don't use JSX/MDX features, add format: md to frontmatter to opt out of MDX parsing entirely and prevent angle-bracket issues."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/contributing/billing.md` at line 1, The Markdown file with the heading
"Contributing to Grida | Billing" is missing YAML frontmatter; add a frontmatter
block at the top containing at minimum "format: md" (e.g., ---\nformat: md\n---)
so the document opts out of MDX parsing; ensure the frontmatter appears before
the existing "# Contributing to Grida | Billing" heading and contains no JSX/MDX
content.
Move this page under an actively maintained docs directory.
This file is introduced under docs/contributing/, but this repository’s docs maintenance scope is limited to docs/wg/** and docs/reference/**.
As per coding guidelines, "When writing documentation, the root ./docs directory is the source of truth, and only actively maintain docs/wg/** and docs/reference/** directories."
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/contributing/billing.md` at line 1, The "Contributing to Grida |
Billing" page lives in an unmaintained docs location; move the page titled
"Contributing to Grida | Billing" into an actively maintained docs subdirectory
(such as the project’s wg or reference docs area), update any
sidebars/navigation or cross-links that referenced the old location, and ensure
frontmatter/metadata (title, permalink) if present reflects the new location so
build/navigation continue to work.
Bug fixes: - e2e teardown: drop early `return` on resource_missing — was skipping the local org delete and leaving orphaned fixtures - fn_stamp_failure: UPSERT + p_event_type. The projector's stripe_event INSERT rolls back with the RAISE, so the prior UPDATE-only stamp silently matched nothing on first failure and the forensic record was lost. Caller passes event.type so the row can be inserted with required type metadata. Defense-in-depth: - Fail closed on stripe_customer_id drift in both fn_attach_stripe_customer and the customer.created/customer.updated projector branch. Mismatched ids now RAISE instead of silently no-oping; Stripe retries while ops fixes the binding. UI polish: - Button asChild around the upgrade Link (avoid invalid <button> in <a>) - Plan-card "Current" badge now matches plan AND interval (was contradictory when toggling Annual on a Monthly sub) Coverage: - 20 new pgTAP assertions: SELECT/INSERT denial on each of the 5 grida_billing internal tables (account, subscription, product_catalogue, stripe_event, audit) for both `anon` and `authenticated` roles. Catches future GRANT loosening or RESTRICTIVE policy drift. Copy: - Soften "the same minute" → "usually within a few minutes" on the payment-failure timing guarantee in docs/platform/billing.mdx
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
editor/lib/billing/__tests__/e2e/fixtures/org.ts (1)
71-75: 💤 Low valueUse Stripe error
codeproperty instead of message regex matching.The current catch blocks use
/No such subscription|resource_missing/i.test(msg)to detect "already gone" errors. Stripe errors expose a structuredcodeproperty (e.g.,'resource_missing') and thestripe.errors.StripeErrortype — matching against the freeform message is fragile and could unintentionally match other errors containing those words. The official Stripe pattern is to checkerr instanceof stripe.errors.StripeError && err.code === "resource_missing".♻️ Proposed changes
const canceled = await stripe.subscriptions .cancel(sub.id) .catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); if (/No such subscription|resource_missing/i.test(msg)) return null; throw err; });Replace with:
const canceled = await stripe.subscriptions .cancel(sub.id) .catch((err: unknown) => { if ( err instanceof stripe.errors.StripeError && err.code === "resource_missing" ) { return null; } throw err; });And at lines 91–98:
try { await stripe.customers.del(customerId); } catch (err) { const msg = err instanceof Error ? err.message : String(err); // "Already deleted" is fine — fall through to the local org delete. // Anything else is a real teardown failure and must not be silently // logged — orphan customers compound across runs and eventually trip // the test sandbox limits. - if (!/No such customer|resource_missing/i.test(msg)) { - throw new Error(`[e2e/org] customer delete failed: ${msg}`); + const isMissing = + err instanceof stripe.errors.StripeError && + err.code === "resource_missing"; + if (!isMissing) { + const msg = err instanceof Error ? err.message : String(err); + throw new Error(`[e2e/org] customer delete failed: ${msg}`); } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts` around lines 71 - 75, Replace fragile regex message-matching in the promise rejection handler (the .catch((err: unknown) => { ... }) block in the org fixture) with a structured Stripe error check: detect Stripe errors using err instanceof stripe.errors.StripeError and then check err.code for expected values (e.g., 'resource_missing' and/or 'no_such_subscription') and return null for those codes; otherwise rethrow the error. Ensure you reference the same catch block and use the stripe.errors.StripeError type and err.code comparisons instead of testing the freeform message.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@supabase/schemas/grida_billing.sql`:
- Around line 627-660: The audit INSERT's CASE diverges from the subscription
UPDATE logic for dispute events; compute the audit status using the same
predicates as the IF/ELSIF blocks (e.g., evaluate p_event_type and
v_dispute_status the same way those branches do) and use that computed value in
the INSERT instead of the current CASE — for example derive a v_audit_status
(using the same checks for 'charge.dispute.created', 'charge.dispute.updated'
with the pause-criteria, and 'charge.dispute.closed' with 'won' -> 'active' and
'lost' -> 'canceled' and 'warning_closed' -> 'active') and insert v_audit_status
so grida_billing.audit.status always matches the subscription updates performed
on grida_billing.subscription (refer to the IF/ELSIF blocks, v_dispute_status,
p_event_type, and the INSERT INTO grida_billing.audit).
---
Nitpick comments:
In `@editor/lib/billing/__tests__/e2e/fixtures/org.ts`:
- Around line 71-75: Replace fragile regex message-matching in the promise
rejection handler (the .catch((err: unknown) => { ... }) block in the org
fixture) with a structured Stripe error check: detect Stripe errors using err
instanceof stripe.errors.StripeError and then check err.code for expected values
(e.g., 'resource_missing' and/or 'no_such_subscription') and return null for
those codes; otherwise rethrow the error. Ensure you reference the same catch
block and use the stripe.errors.StripeError type and err.code comparisons
instead of testing the freeform message.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro
Run ID: 93e7bdb0-e733-4392-ae36-69186822a075
📒 Files selected for processing (10)
database/database-generated.types.tsdocs/platform/billing.mdxeditor/app/(api)/private/webhooks/stripe/route.tseditor/app/(site)/organizations/[organization_name]/settings/billing/_view.tsxeditor/app/(site)/organizations/[organization_name]/settings/billing/upgrade/_view.tsxeditor/lib/billing/__tests__/e2e/fixtures/org.tseditor/lib/billing/index.tssupabase/migrations/20260506132900_grida_billing.sqlsupabase/schemas/grida_billing.sqlsupabase/tests/test_grida_billing_test.sql
🚧 Files skipped from review as they are similar to previous changes (5)
- editor/app/(api)/private/webhooks/stripe/route.ts
- database/database-generated.types.ts
- supabase/tests/test_grida_billing_test.sql
- editor/lib/billing/index.ts
- supabase/migrations/20260506132900_grida_billing.sql
| IF p_event_type = 'charge.dispute.created' | ||
| OR (p_event_type IN ('charge.dispute.updated') | ||
| AND v_dispute_status IN ('warning_needs_response','warning_under_review', | ||
| 'needs_response','under_review')) THEN | ||
| UPDATE grida_billing.subscription | ||
| SET status = 'paused', updated_at = now() | ||
| WHERE stripe_subscription_id = v_sub_id | ||
| AND status NOT IN ('canceled'); | ||
| ELSIF p_event_type = 'charge.dispute.closed' | ||
| AND v_dispute_status IN ('won','warning_closed') THEN | ||
| UPDATE grida_billing.subscription | ||
| SET status = 'active', updated_at = now() | ||
| WHERE stripe_subscription_id = v_sub_id | ||
| AND status = 'paused'; | ||
| ELSIF p_event_type = 'charge.dispute.closed' | ||
| AND v_dispute_status = 'lost' THEN | ||
| UPDATE grida_billing.subscription | ||
| SET status = 'canceled', updated_at = now() | ||
| WHERE stripe_subscription_id = v_sub_id; | ||
| END IF; | ||
|
|
||
| INSERT INTO grida_billing.audit ( | ||
| organization_id, operation, stripe_event_id, stripe_subscription_id, | ||
| event_type, status, note | ||
| ) VALUES ( | ||
| v_org_id, 'webhook.received', p_event_id, v_sub_id, | ||
| p_event_type, | ||
| CASE | ||
| WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won' THEN 'active' | ||
| WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled' | ||
| ELSE 'paused' | ||
| END, | ||
| format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status) | ||
| ); |
There was a problem hiding this comment.
Audit row status diverges from actual subscription status in two dispute paths.
The audit-row status CASE (lines 654-658) only encodes 'won' and 'lost' explicitly and falls through to 'paused' for everything else. That mis-stamps two real cases:
charge.dispute.closedwithv_dispute_status = 'warning_closed': the IF chain at lines 635-640 updates the subscription tostatus='active', but the audit row records'paused'.charge.dispute.updatedwith any status outside the matched set (e.g.won,lost,warning_closed,charge_refunded): no UPDATE runs, but the audit still records'paused'— claiming a transition that never happened.
Both make later forensic reconciliation against grida_billing.subscription.status unreliable.
🛠️ Suggested fix — derive the audit status from the same predicate as the UPDATE
- INSERT INTO grida_billing.audit (
- organization_id, operation, stripe_event_id, stripe_subscription_id,
- event_type, status, note
- ) VALUES (
- v_org_id, 'webhook.received', p_event_id, v_sub_id,
- p_event_type,
- CASE
- WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'won' THEN 'active'
- WHEN p_event_type = 'charge.dispute.closed' AND v_dispute_status = 'lost' THEN 'canceled'
- ELSE 'paused'
- END,
- format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status)
- );
+ INSERT INTO grida_billing.audit (
+ organization_id, operation, stripe_event_id, stripe_subscription_id,
+ event_type, status, note
+ ) VALUES (
+ v_org_id, 'webhook.received', p_event_id, v_sub_id,
+ p_event_type,
+ CASE
+ WHEN p_event_type = 'charge.dispute.closed'
+ AND v_dispute_status IN ('won','warning_closed') THEN 'active'
+ WHEN p_event_type = 'charge.dispute.closed'
+ AND v_dispute_status = 'lost' THEN 'canceled'
+ WHEN p_event_type = 'charge.dispute.created'
+ OR (p_event_type = 'charge.dispute.updated'
+ AND v_dispute_status IN ('warning_needs_response','warning_under_review',
+ 'needs_response','under_review')) THEN 'paused'
+ ELSE NULL -- no-op branch (e.g. dispute.updated → final status)
+ END,
+ format('dispute_id=%s status=%s', v_dispute_id, v_dispute_status)
+ );🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@supabase/schemas/grida_billing.sql` around lines 627 - 660, The audit
INSERT's CASE diverges from the subscription UPDATE logic for dispute events;
compute the audit status using the same predicates as the IF/ELSIF blocks (e.g.,
evaluate p_event_type and v_dispute_status the same way those branches do) and
use that computed value in the INSERT instead of the current CASE — for example
derive a v_audit_status (using the same checks for 'charge.dispute.created',
'charge.dispute.updated' with the pause-criteria, and 'charge.dispute.closed'
with 'won' -> 'active' and 'lost' -> 'canceled' and 'warning_closed' ->
'active') and insert v_audit_status so grida_billing.audit.status always matches
the subscription updates performed on grida_billing.subscription (refer to the
IF/ELSIF blocks, v_dispute_status, p_event_type, and the INSERT INTO
grida_billing.audit).
|
Follow-up: #708 (production backfill for pre-migration orgs + drop dev test-card hint from upgrade page). |
…d-cleanups fix(billing): post-#705 follow-ups — backfill + cleanup
Summary
Grida now has a working paid-plan billing system. Customers can subscribe to Pro or Team, manage their subscription themselves, and receive invoices — all backed by Stripe.
This is v1. Multi-seat billing is intentionally deferred — see "Known limitations" below.
What this delivers
quantity: 1. Stripe bills the flat per-plan price ($20/mo Pro, $60/mo Team; $192/yr Pro, $576/yr Team).stripe_eventPK +ON CONFLICT DO NOTHING), projected intogrida_billing.*via a single PL/pgSQL function. No path mutates billing state outside the projector.Architecture highlights
editor/lib/billing/index.tsis the only allowed import site for the Stripe SDK;oxlintenforces this withno-restricted-imports.grida_billingis a locked-down schema (REVOKEd fromauthenticated/anon); the editor reads throughpublic.v_billing_*views with RLS.service_role.workspace.*inline at every callsite (no aliasing — keepsgrep service_rolehonest).BILLING_E2E=1(refuses to run withoutsk_test_*andBILLING_TEST_MODE=true); 37 pgTAP scenarios cover RLS, projector branches, idempotency, and annual catalogue resolution.Known limitations (v1)
subscription.quantitystays at 1 — Stripe bills the flat plan price regardless of member count. Tracked: GRIDA-60. The seat-sync triggers were dropped before launch because they mutated local state without telling Stripe.getBillingSummarymakes one Stripe API call per page load to derive the currentinterval(Stripe's only source for it). Acceptable for v1; future work projectsintervalonto the local subscription row from the webhook.Test plan
/organizations/<org>/settings/billing/upgrade(monthly) → Stripe Checkout → returns to billing page → plan flips to Pro within secondssubscription_update_confirmflow → charged prorated difference → dashboard updatescancels at period endpayment_method_updateflow → return to billing page4000 0000 0000 0002→ past-due banner + "Update payment method" CTA, plan-change buttons disabledSummary by CodeRabbit
New Features
Documentation
Tests
Chores